| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411 |
- import { ident, safeSql } from '@supabase/pg-meta'
- import type { PGTable } from '@supabase/pg-meta'
- import { PermissionAction } from '@supabase/shared-types/out/constants'
- import { LOCAL_STORAGE_KEYS, useParams } from 'common'
- import { Search, X } from 'lucide-react'
- import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs'
- import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
- import { toast } from 'sonner'
- import { Button } from 'ui'
- import { Input } from 'ui-patterns/DataInputs/Input'
- import { PageContainer } from 'ui-patterns/PageContainer'
- import {
- PageHeader,
- PageHeaderAside,
- PageHeaderDescription,
- PageHeaderMeta,
- PageHeaderSummary,
- PageHeaderTitle,
- } from 'ui-patterns/PageHeader'
- import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
- import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
- import { useIsInlineEditorEnabled } from '@/components/interfaces/Account/Preferences/useDashboardSettings'
- import { useIsRLSTesterEnabled } from '@/components/interfaces/App/FeaturePreview/FeaturePreviewContext'
- import { Policies } from '@/components/interfaces/Auth/Policies/Policies'
- import { PoliciesDataProvider } from '@/components/interfaces/Auth/Policies/PoliciesDataContext'
- import { getGeneralPolicyTemplates } from '@/components/interfaces/Auth/Policies/PolicyEditorModal/PolicyEditorModal.constants'
- import { PolicyEditorPanel } from '@/components/interfaces/Auth/Policies/PolicyEditorPanel'
- import {
- generatePolicyUpdateSQL,
- type Policy,
- } from '@/components/interfaces/Auth/Policies/PolicyTableRow/PolicyTableRow.utils'
- import { RLSTesterSheet } from '@/components/interfaces/Auth/RLSTester/RLSTesterSheet'
- import AuthLayout from '@/components/layouts/AuthLayout/AuthLayout'
- import { DefaultLayout } from '@/components/layouts/DefaultLayout'
- import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider'
- import { AlertError } from '@/components/ui/AlertError'
- import { AutoEnableRLSNotice } from '@/components/ui/AutoEnableRLSNotice'
- import { BannerRlsTester } from '@/components/ui/BannerStack/Banners/BannerRlsTester'
- import { useBannerStack } from '@/components/ui/BannerStack/BannerStackProvider'
- import { DocsButton } from '@/components/ui/DocsButton'
- import { NoPermission } from '@/components/ui/NoPermission'
- import { SchemaSelector } from '@/components/ui/SchemaSelector'
- import { Shortcut } from '@/components/ui/Shortcut'
- import { useProjectPostgrestConfigQuery } from '@/data/config/project-postgrest-config-query'
- import { useDatabasePoliciesQuery } from '@/data/database-policies/database-policies-query'
- import { useTablesQuery } from '@/data/tables/tables-query'
- import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
- import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
- import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
- import { useIsProtectedSchema } from '@/hooks/useProtectedSchemas'
- import { DOCS_URL } from '@/lib/constants'
- import { onSearchInputEscape } from '@/lib/keyboard'
- import { useEditorPanelStateSnapshot } from '@/state/editor-panel-state'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- import { useShortcut } from '@/state/shortcuts/useShortcut'
- import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state'
- import type { NextPageWithLayout } from '@/types'
- /**
- * Filter tables by table name and policy name
- *
- * @param tables list of table
- * @param policies list of policy
- * @param searchString filter keywords
- *
- * @returns list of table
- */
- const getTableFilterState = (tables: PGTable[], policies: Array<Policy>, searchString?: string) => {
- const sortedTables = tables.slice().sort((a, b) => a.name.localeCompare(b.name))
- const visibleTableIds = new Set<number>()
- if (!searchString) {
- sortedTables.forEach((table) => visibleTableIds.add(table.id))
- return { tables: sortedTables, visibleTableIds }
- }
- const filter = searchString.toLowerCase()
- const matchingPolicyKeys = new Set(
- policies
- .filter((policy: Policy) => policy.name.toLowerCase().includes(filter))
- .map((policy) => `${policy.schema}.${policy.table}`)
- )
- sortedTables.forEach((table) => {
- const matches =
- table.name.toLowerCase().includes(filter) ||
- table.id.toString() === filter ||
- matchingPolicyKeys.has(`${table.schema}.${table.name}`)
- if (matches) {
- visibleTableIds.add(table.id)
- }
- })
- return { tables: sortedTables, visibleTableIds }
- }
- const AuthPoliciesPage: NextPageWithLayout = () => {
- const [schema, setSchema] = useQueryState(
- 'schema',
- parseAsString.withDefault('public').withOptions({ history: 'replace' })
- )
- const [searchString, setSearchString] = useQueryState(
- 'search',
- parseAsString.withDefault('').withOptions({ history: 'replace', clearOnDefault: true })
- )
- const deferredSearchString = useDeferredValue(searchString)
- const [selectedIdToEdit, setSelectedIdToEdit] = useQueryState(
- 'edit',
- parseAsString.withOptions({ history: 'push', clearOnDefault: true })
- )
- const { ref: projectRef } = useParams()
- const { data: project } = useSelectedProjectQuery()
- const { data: postgrestConfig } = useProjectPostgrestConfigQuery({ projectRef: project?.ref })
- const isInlineEditorEnabled = useIsInlineEditorEnabled()
- const rlsTesterEnabled = useIsRLSTesterEnabled()
- const { openSidebar } = useSidebarManagerSnapshot()
- const {
- setValue: setEditorPanelValue,
- setTemplates: setEditorPanelTemplates,
- setInitialPrompt: setEditorPanelInitialPrompt,
- } = useEditorPanelStateSnapshot()
- const [selectedTable, setSelectedTable] = useState<string>()
- const [showCreatePolicy, setShowCreatePolicy] = useQueryState(
- 'new',
- parseAsBoolean.withDefault(false).withOptions({ history: 'push', clearOnDefault: true })
- )
- const [schemaSelectorOpen, setSchemaSelectorOpen] = useState(false)
- const searchInputRef = useRef<HTMLInputElement>(null)
- useShortcut(
- SHORTCUT_IDS.LIST_PAGE_FOCUS_SEARCH,
- () => {
- searchInputRef.current?.focus()
- searchInputRef.current?.select()
- },
- { label: 'Search policies' }
- )
- useShortcut(SHORTCUT_IDS.LIST_PAGE_RESET_FILTERS, () => setSearchString(''))
- const { isSchemaLocked } = useIsProtectedSchema({ schema: schema, excludedSchemas: ['realtime'] })
- const { addBanner, dismissBanner } = useBannerStack()
- const [isAutoEnableRLSMinimized] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.RLS_EVENT_TRIGGER_BANNER_DISMISSED(projectRef ?? ''),
- false
- )
- const [isRlsTesterBannerDismissed] = useLocalStorageQuery(
- LOCAL_STORAGE_KEYS.RLS_TESTER_BANNER_DISMISSED(projectRef ?? ''),
- false
- )
- const {
- data: policies = [],
- isPending: isLoadingPolicies,
- isError: isPoliciesError,
- isSuccess: isPoliciesSuccess,
- error: policiesError,
- } = useDatabasePoliciesQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
- const selectedPolicyToEdit = policies.find((policy) => policy.id.toString() === selectedIdToEdit)
- const {
- data: tables,
- isPending: isLoading,
- isSuccess,
- isError,
- error,
- } = useTablesQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- schema: schema,
- })
- const { tables: tablesWithVisibility, visibleTableIds } = useMemo(
- () => getTableFilterState(tables ?? [], policies ?? [], searchString),
- [tables, policies, searchString]
- )
- const exposedSchemas = useMemo(() => {
- if (!postgrestConfig?.db_schema) return []
- return postgrestConfig.db_schema
- .split(',')
- .map((schema) => schema.trim())
- .filter((schema) => schema.length > 0)
- }, [postgrestConfig?.db_schema])
- const { can: canReadPolicies, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
- PermissionAction.TENANT_SQL_ADMIN_READ,
- 'policies'
- )
- const handleSelectCreatePolicy = useCallback(
- (table: string) => {
- setSelectedTable(table)
- setSelectedIdToEdit(null)
- setShowCreatePolicy(true)
- if (isInlineEditorEnabled) {
- const defaultSql = safeSql`create policy "replace_with_policy_name"
- on ${ident(schema)}.${ident(table)}
- for select
- to authenticated
- using (
- true -- Write your policy condition here
- );`
- setEditorPanelInitialPrompt('Create a new RLS policy that...')
- setEditorPanelValue(defaultSql)
- setEditorPanelTemplates([])
- openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
- } else {
- setShowCreatePolicy(true)
- }
- },
- [isInlineEditorEnabled, openSidebar, schema]
- )
- const handleSelectEditPolicy = useCallback(
- (policy: Policy) => {
- setSelectedTable(undefined)
- if (isInlineEditorEnabled) {
- setEditorPanelInitialPrompt(`Update the RLS policy with name "${policy.name}" that...`)
- setEditorPanelValue(generatePolicyUpdateSQL(policy))
- const templates = getGeneralPolicyTemplates(policy.schema, policy.table).map(
- (template) => ({
- name: template.templateName,
- description: template.description,
- content: template.statement,
- })
- )
- setEditorPanelTemplates(templates)
- openSidebar(SIDEBAR_KEYS.EDITOR_PANEL)
- } else {
- setSelectedIdToEdit(policy.id.toString())
- }
- },
- [isInlineEditorEnabled, openSidebar]
- )
- const handleResetSearch = useCallback(() => setSearchString(''), [setSearchString])
- useEffect(() => {
- if (rlsTesterEnabled) return
- if (!isRlsTesterBannerDismissed) {
- addBanner({
- id: 'rls-tester-banner',
- isDismissed: false,
- content: <BannerRlsTester />,
- priority: 3,
- })
- } else {
- dismissBanner('rls-tester-banner')
- }
- return () => {
- dismissBanner('rls-tester-banner')
- }
- }, [addBanner, dismissBanner, isRlsTesterBannerDismissed, rlsTesterEnabled])
- useEffect(() => {
- if (selectedIdToEdit && isPoliciesSuccess && !selectedPolicyToEdit) {
- toast(`Policy ID ${selectedIdToEdit} cannot be found`)
- setSelectedIdToEdit(null)
- }
- }, [selectedIdToEdit, selectedPolicyToEdit, isPoliciesSuccess, setSelectedIdToEdit])
- const isUpdatingPolicy = !!selectedIdToEdit
- if (isPermissionsLoaded && !canReadPolicies) {
- return <NoPermission isFullPage resourceText="view this project's RLS policies" />
- }
- return (
- <>
- <PageHeader size="large">
- <PageHeaderMeta>
- <PageHeaderSummary>
- <PageHeaderTitle>Policies</PageHeaderTitle>
- <PageHeaderDescription>
- Manage Row Level Security policies for your tables
- </PageHeaderDescription>
- </PageHeaderSummary>
- <PageHeaderAside>
- {isAutoEnableRLSMinimized && <AutoEnableRLSNotice iconOnly />}
- <DocsButton href={`${DOCS_URL}/learn/auth-deep-dive/auth-row-level-security`} />
- {rlsTesterEnabled && <RLSTesterSheet handleSelectEditPolicy={handleSelectEditPolicy} />}
- </PageHeaderAside>
- </PageHeaderMeta>
- </PageHeader>
- <PageContainer size="large">
- <PageSection>
- {!isAutoEnableRLSMinimized && <AutoEnableRLSNotice />}
- <PageSectionContent>
- <div className="mb-4 flex flex-row gap-x-2">
- <Shortcut
- id={SHORTCUT_IDS.LIST_PAGE_FOCUS_SCHEMA}
- onTrigger={() => setSchemaSelectorOpen(true)}
- side="bottom"
- tooltipOpen={schemaSelectorOpen ? false : undefined}
- >
- <SchemaSelector
- className="w-full lg:w-[180px]"
- size="tiny"
- align="end"
- showError={false}
- selectedSchemaName={schema}
- open={schemaSelectorOpen}
- onOpenChange={setSchemaSelectorOpen}
- onSelectSchema={(schemaName) => {
- setSchema(schemaName)
- setSearchString('')
- }}
- />
- </Shortcut>
- <Input
- ref={searchInputRef}
- size="tiny"
- placeholder="Filter tables and policies"
- className="block w-full lg:w-52"
- containerClassName="[&>div>svg]:-mt-0.5"
- value={searchString || ''}
- onChange={(e) => {
- const str = e.target.value
- setSearchString(str)
- }}
- onKeyDown={onSearchInputEscape(searchString || '', setSearchString)}
- icon={<Search size={14} />}
- actions={
- searchString ? (
- <Button
- size="tiny"
- type="text"
- className="p-0 h-5 w-5"
- icon={<X />}
- onClick={() => setSearchString('')}
- />
- ) : null
- }
- />
- </div>
- {isLoading && <GenericSkeletonLoader />}
- {isError && <AlertError error={error} subject="Failed to retrieve tables" />}
- {isSuccess && (
- <PoliciesDataProvider
- policies={policies ?? []}
- isPoliciesLoading={isLoadingPolicies}
- isPoliciesError={isPoliciesError}
- policiesError={policiesError ?? undefined}
- exposedSchemas={exposedSchemas}
- >
- <Policies
- search={deferredSearchString}
- schema={schema}
- tables={tablesWithVisibility}
- hasTables={(tables ?? []).length > 0}
- isLocked={isSchemaLocked}
- visibleTableIds={visibleTableIds}
- onSelectCreatePolicy={handleSelectCreatePolicy}
- onSelectEditPolicy={handleSelectEditPolicy}
- onResetSearch={handleResetSearch}
- />
- </PoliciesDataProvider>
- )}
- <PolicyEditorPanel
- visible={showCreatePolicy || (isUpdatingPolicy && !!selectedPolicyToEdit)}
- schema={schema}
- searchString={searchString}
- selectedTable={isUpdatingPolicy ? undefined : selectedTable}
- selectedPolicy={isUpdatingPolicy ? selectedPolicyToEdit : undefined}
- onSelectCancel={() => {
- setSelectedTable(undefined)
- if (isUpdatingPolicy) {
- setSelectedIdToEdit(null)
- } else {
- setShowCreatePolicy(false)
- }
- }}
- authContext="database"
- />
- </PageSectionContent>
- </PageSection>
- </PageContainer>
- </>
- )
- }
- AuthPoliciesPage.getLayout = (page) => (
- <DefaultLayout>
- <AuthLayout title="Policies">{page}</AuthLayout>
- </DefaultLayout>
- )
- export default AuthPoliciesPage
|